跳到主要内容

Docker 的工作原理

Docker 是什么?

Docker 可以打包应用到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,且之间不会有任何接口冲突

它由以下部分组成

  • 镜像(Images):Docker 镜像是用于创建 Docker 容器的模板
  • 容器(Container):容器是独立运行的一个或者一组应用。镜像相当于类,容器相当于类的实例
  • Docker 客户端(Client):Docker 客户端通过命令行或者其他工具使用 Docker API 与 Docker 的守护进程通信
  • Docker 主机(Host):一个物理或者虚拟的机器用于执行 Docker 守护进程和容器
  • Docker 守护进程:是 Docker 服务端进程,负责支撑 Docker 容器的运行以及镜像的管理
  • Docker 仓库:用于保存镜像,类似于代码托管中心,Docker Hub 提供了庞大的镜像集合供使用(类似于 npm)

传统虚拟机和 Docker 的区别

Docker 容器是在操作系统层面上实现虚拟化,直接复用本地主机的操作系统,而传统虚拟机则是在硬件层面实现 虚拟化,与传统的虚拟机相比,Docker 优势体现在启动速度极快、占用体积小

20220414104750

在那些虚拟机之下是宿主机的操作系统与一个管理程序,它将物理硬件资源分成较小部分的 虚拟硬件资源, 从而被每个虚拟机里的操作系统使用。 运行在那些虚拟机里的 应用程序会执行虚拟机操作系统的系统调用, 然后虚拟机内核会通过管理程序在宿主机上的物理来CPU执行x86指令。

20220414110338

多个容器则会完全执行运行在宿主机上的同一个内核的系统调用, 此内核是唯一一个在宿主机操作系统上执行x86指令的内核。 CPU也不需要做任何对虚拟机能做那样的虚拟化.

20220414110403

Docker 镜像加载原理

docker 的镜像实际上由一层一层的文件系统组成,这种层级的文件系统 UnionFS

Union 文件系统是 Docker 镜像的基础。镜像可以通过分层来进行集成,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像,联合加载会把各层文件系统叠加起来,这样最终的文件系统会包含所有底层文件和目录。

而这些镜像也只需记录容器修改的部分(就像 git 那样)

image.png

提示

Linux 系统实际上分 bootfs 和 roorfs

  • bootfs:主要就是负责引导内核到内存,这个所有发行版的 Linux 都是一样的
  • roorfs:就是各种不同的操作系统发行版

所以实际下载的 Ubuntu 镜像就是是这个 roorfs 部分

构建镜像时,Dockerfile 中每一条单独的指令都会创建一个新层。 镜像构建的过程中,拉取基础镜 像所有分层之后,再在新的一层进行下一步操作

UnionFS 系统的好处

采用这种分层结构最大的一个好处就是共享资源,比如有多个镜像都从相同的 base 镜像构建而来,那么宿主机只需要在磁盘上保存一份 base 镜像

同时内存中也只需要加载一份 base 镜像,就可以为所有容器服务了。而且镜像的每一层都可以被共享。

docker 镜像都是只读的,当容器启动时,一个新的可写层被加载到镜像的顶部。这一层通常被称作 “容器层” ,“容器层” 之下的都叫镜像层。这样就只需记录容器修改的部分就行了(就像 git 那样)

所以可以注意到 pull 镜像时也是一层一层的下载的

d1e017099d17: Pull complete
717377b83d5c: Pull complete
...

容器实现隔离机制介绍

如果多个进程运行在同一个操作系统上,那容器到底是怎样隔离它们的。

有两个机制可用:

第一个是 Linux 命名空间, 它使每个进程只看到它自己的系统视图(文件、进程、网络接口、主机名等); 第二个是 Linux 控制组 (cgroups),它限制了进程能使用的资源量(CPU、 内存、 网络带宽等)。

用命名空间隔离进程可见性

默认情况下,每个 Linux 系统最初仅有一个命名空间。所有系统资源(诸如文件系统、 用户 ID、 网络接口等) 属于这一个命名空间。

如下检查进程的命名空间

# $$ 这个程式的PID(脚本运行的当前进程ID号)
$ ls -l /proc/$$/ns
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 net -> 'net:[4026532008]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 time -> 'time:[4026531834]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 user -> 'user:[4026531837]'
lrwxrwxrwx 1 alsritter alsritter 0 Apr 14 16:25 uts -> 'uts:[4026531838]'

具体的命名空间作用参考 Linux 命名空间的笔记

但是你能创建额外的命名空间,以及在它们之间组织资源。 对于一个进程,可以在其中一个命名空间中运行它。进程将只能看到同一个命名空间下的资源。当然,会存在多种类型的多个命名空间,所以一个进程不单单只属于某一个命名空间, 而属于每个类型的一个命名空间。

存在以下类型的命名空间:

  • Mount (mnt)
  • Process ID (pid)
  • Network (net)
  • Inter-process communicaion (ipd)
  • UTS
  • User ID (user)

每种命名空间被用来隔离一组特定的资源。 例如, UTS 命名空间决定了运行在命名空间里的进程能看见哪些主机名和域名。 通过分派两个不同的 UTS 命名空间给一对进程, 能使它们看见不同的本地主机名。 换句话说, 这两个进程就好像正在两个不同的机器上运行一样(至少就主机名而言是这样的)。

同样地, 一个进程属于什么 Network 命名空间决定了运行在进程里的应用程序能看见什么网络接口。 每个网络接口属于一个命名空间, 但是可以从一个命名空间转移到另 一个。 每个容器都使用它自己的网络命名空间, 因此每个容器仅能看见它自己的一组网络接口

用 cgroups 隔离进程资源

另外的隔离性就是限制容器能使用的系统资源。 这通过 cgroups 来实现。 Cgroups 是 control groups 的缩写,是Linux内核提供的一种可以限制,记录,隔离进程组(process groups)所使用物理资源的机制。

$ cat /proc/$$/cgroup

# cgroups 主要由 task,cgroup,subsystem 以及 hierarchy 组成
12:perf_event:/
11:pids:/user.slice/user-1000.slice/session-1.scope
10:rdma:/
9:freezer:/1@dde/uiapps/18
8:devices:/user.slice
7:memory:/1@dde/uiapps/18
6:cpu,cpuacct:/user.slice
5:net_cls,net_prio:/
4:cpuset:/
3:blkio:/1@dde/uiapps/18
2:hugetlb:/
1:name=systemd:/user.slice/user-1000.slice/session-1.scope
0::/user.slice/user-1000.slice/session-1.scope

Linux 系统每个进程都可以自由竞争系统资源,有时候会导致一些次要进程占用了系统某个资源(如CPU)的绝大部分,主要进程就不能很好地执行,从而影响系统效率,重则在 linux 资源耗尽时可能会引起错杀进程。因此 linux 引入了 linux cgroups 来控制进程资源,让进程更可控。

一个进程的资源(CPU、 内存、 网络带宽等)使用量不能超出被分配的量。 这种方式下, 进程不能过分使用为其他进程保留的资源, 这和进程运行在不同的机器上是类似的。

镜像构建原理与上下文

如果注意,会看到 docker build 命令最后有一个 .。这个 . 表示当前目录,这个路径并不止是在指定 Dockerfile 所在路径,而是指上下文路径

首先我们要理解 docker build 的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API。

而如 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。

备注

构建过程不是由 Docker 客户端进行的,而是将整个目录的文件上传到 Docker 守护进程并在那里进行的。

当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?

这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

如果在 Dockerfile 中这么写:

COPY ./package.json /app/

这并不是要复制执行 docker build 命令所在的目录下的 package.json,也不是复制 Dockerfile 所在目录下的 package.json,而是复制 上下文(context) 目录下的 package.json。

因此,COPY 这类指令中的源文件的路径都是相对路径。这也是为什么 COPY ../package.json /app 或者 COPY /opt/xxxx /app 无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

将 Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行后,在发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误。

一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

References